Skip to content

New Module: ID5 User ID#4318

Open
pkowalski-id5 wants to merge 8 commits intoprebid:masterfrom
id5io:pr/id5id-module
Open

New Module: ID5 User ID#4318
pkowalski-id5 wants to merge 8 commits intoprebid:masterfrom
id5io:pr/id5id-module

Conversation

@pkowalski-id5
Copy link

🔧 Type of changes

  • new module

✨ What's the context?

ID5 wants to have its own module that can enrich bid requests with ID5 universal identifiers. This module fetches identity signals from ID5's API and automatically injects them into OpenRTB bid requests as Extended
Identifiers (EIDs).

🧠 Rationale behind the change

Why a dedicated module?

  • ID5 identifiers enhance user matching capabilities across the programmatic ecosystem
  • Having a dedicated module allows PBS operators to enable ID5 enrichment without requiring publisher-side changes
  • The module respects all major privacy signals (GDPR, CCPA, COPPA, GPP)

Key design decisions:

  • Two-hook architecture: Separate fetch and inject hooks to optimize performance
    • Fetch hook (ProcessedAuctionRequestHook): Initiates async ID5 API call early in the request lifecycle
    • Inject hook (BidderRequestHook): Injects EIDs into each bidder request only when not already present
  • Non-blocking async implementation: ID5 fetch doesn't block the auction processing
  • Preservation of existing IDs: Module checks if ID5 EID already exists before fetching/injecting
  • Flexible filtering: Account, country, bidder filters and sampling for gradual rollout
  • Configurable partner ID provider: Interface allows custom logic for determining partner ID per request

Trade-offs:

  • Requires both hooks to be configured in execution plan (fetch + inject) for the module to function
  • Additional HTTP call to ID5 API adds latency (mitigated by async execution and timeout awareness)

🔎 New Bid Adapter Checklist

Not applicable - this is a module, not a bid adapter

🧪 Test plan

Unit Tests:

  • Comprehensive unit test coverage for both hooks
  • Filter logic tested (account, country, bidder, sampling)
  • Privacy signal handling tested (GDPR, CCPA, COPPA, GPP)
  • Edge cases covered (timeouts, existing IDs, empty responses)

Integration Tests:

  • Full PBS instance integration tests with the module enabled
  • Tests verify complete fetch + inject flow
  • WireMock-based local testing setup included in sample/ directory

Manual Testing:

  • Local end-to-end testing with WireMock mocks
  • Sample configurations provided for testing different scenarios
  • Debug logging available for troubleshooting

The module has been tested with various configurations and filter combinations to ensure safe production deployment.

🏎 Quality check

  • Are your changes following our code style guidelines?
  • Are there any breaking changes in your code? No breaking changes
  • Does your test coverage exceed 90%? Yes
  • Are there any erroneous console logs, debuggers or leftover code in your changes? No

🤔 Questions for reviewers

1. Hook invocation status/action handling:
We would particularly appreciate review of our hook invocation status and action returns. Please verify that we're correctly returning:

  • Appropriate InvocationStatus values in different scenarios (success, failure, skipped)
  • Correct InvocationAction to control execution flow
  • Proper handling of edge cases (timeouts, errors, existing IDs)

2. Execution plan configuration simplification:
Both hooks (fetch + inject) are required for the module to function - the module is non-functional if either hook is missing from the execution plan. Currently, operators must configure both hooks separately in the
execution plan:

stages:
  processed-auction-request:
    groups:
      - hook-sequence:
          - module-code: "id5-user-id"
            hook-impl-code: "id5-user-id-fetch-hook"
  bidder-request:
    groups:
      - hook-sequence:
          - module-code: "id5-user-id"
            hook-impl-code: "id5-user-id-inject-hook"

Question: Is there a way to simplify this configuration? For example:

  • A single module registration that automatically registers both required hooks?
  • A validation mechanism that warns when one hook is configured without the other?
  • Any other pattern for "atomic" multi-hook modules?

@marki1an marki1an requested review from And1sS and osulzhenko and removed request for And1sS December 31, 2025 11:15
@osulzhenko osulzhenko changed the title ID5ID userId module New Module: ID5 User ID Jan 2, 2026
@pkowalski-id5
Copy link
Author

@CTMBNara Thank you for the review. I've updated the code. Could you please take another look?

@pkowalski-id5 pkowalski-id5 requested a review from CTMBNara March 2, 2026 10:56
Comment on lines +36 to +38
@PropertySource(
value = "classpath:/module-config/id5-user-id.yaml",
factory = YamlPropertySourceFactory.class)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this. Use --spring.config.additional-location or something similar to it

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me that this is actually the standard approach, as we already have several similar examples in the codebase implemented in the same way. Each bidder follows this pattern, and other modules, like i.e. https://github.com/prebid/prebid-server-java/blob/a35ff7eafb5bee500866a06f3186cb4d4c60c1f8/extra/modules/confiant-ad-quality/src/main/java/org/prebid/server/hooks/modules/com/confiant/adquality/config/ConfiantAdQualityModuleConfiguration.java#L27C1-L27C17
The goal here is to provide default values in the simplest possible way. Defaults are not something users are usually expected to change, so they should work out of the box without any additional configuration.
The suggested change would require adding specific JVM runtime parameters for the defaults to be loaded. This seems to go against the idea of defaults. With the current solution, the behavior is enabled only when the module is on the classpath, which means someone intentionally included it.

Copy link
Collaborator

@CTMBNara CTMBNara Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pkowalski-id5 Bidders is а core feature of prebid-server, while modules are optional. We don't stick with the idea to require separate file for modules configuration. Each module may have config in main .yaml, separate, etc.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for the example you mentioned above: historically, this module has passed a "very light test", and we do not recommend it as an example.

factory = YamlPropertySourceFactory.class)
public class Id5UserIdModuleConfiguration {

private static final Logger LOG = LoggerFactory.getLogger(Id5UserIdModuleConfiguration.class);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOG -> logger (it's excluded in checkstyle check, so you can use lowercase name for static final)

@Slf4j
public class HttpFetchClient implements FetchClient {

private static final Logger LOG = LoggerFactory.getLogger(HttpFetchClient.class);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

VersionInfo versionInfo,
Id5IdModuleProperties id5IdModuleProperties,
UserFpdActivityMask userFpdActivityMask) {
this.fetchUrl = Objects.requireNonNull(endpoint);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add empty line after multi-line declaration

public Future<Id5UserId> fetch(long partnerId,
AuctionRequestPayload payload,
AuctionInvocationContext invocationContext) {
final FetchRequest fetchRequest = createFetchRequest(partnerId, payload, invocationContext);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

final FetchRequest fetchRequest = createFetchRequest(partnerId, payload, invocationContext);
final String body = mapper.encodeToString(fetchRequest);
final MultiMap headers = HttpUtil.headers();
final String url = String.format("%s/%s.json", fetchUrl, partnerId);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"%s/%s.json".formatted(fetchUrl, partnerId)

return fetchRequestBuilder.build();
}

private BidRequest getMaskedBidRequest(BidRequest bidRequest, AuctionInvocationContext invocationContext) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getMaskedBidRequest -> maskBidRequest

Comment on lines +92 to +94
private FetchRequest createFetchRequest(long partnerId,
AuctionRequestPayload payload,
AuctionInvocationContext invocationContext) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

privacyContext and privacyContext.getPrivacy() != null can't be null at this point

    private FetchRequest createFetchRequest(long partnerId,
                                            AuctionRequestPayload payload,
                                            AuctionInvocationContext invocationContext) {

        final BidRequest bidRequest = maskBidRequest(payload.bidRequest(), invocationContext);
        final Privacy privacy = invocationContext.auctionContext().getPrivacyContext().getPrivacy();

        final Optional<Device> device = Optional.ofNullable(bidRequest.getDevice());
        final Optional<Site> site = Optional.ofNullable(bidRequest.getSite());

        return FetchRequest.builder()
                .trace(invocationContext.debugEnabled())
                .partnerId(partnerId)
                .origin("pbs-java")
                .version(versionInfo.getVersion())
                .timestamp(clock.instant().toString())
                .provider(id5IdModuleProperties.getProviderName())
                .providerMetadata(createProviderMetadata(bidRequest))
                .bundle(Optional.ofNullable(bidRequest.getApp()).map(App::getBundle).orElse(null))
                .domain(site.map(Site::getDomain).orElse(null))
                .maid(device.map(Device::getIfa).orElse(null))
                .userAgent(device.map(Device::getUa).orElse(null))
                .ref(site.map(Site::getRef).orElse(null))
                .ipv4(device.map(Device::getIp).orElse(null))
                .ipv6(device.map(Device::getIpv6).orElse(null))
                .att(device
                        .map(Device::getExt)
                        .map(ExtDevice::getAtts)
                        .map(String::valueOf)
                        .orElse(null))
                .coppa(Objects.toString(privacy.getCoppa(), null))
                .usPrivacy(privacy.getCcpa().getUsPrivacy())
                .gppString(privacy.getGpp())
                .gppSid(toStringOrNull(privacy.getGppSid()))
                .gdpr(privacy.getGdpr())
                .gdprConsent(privacy.getConsentString())
                .build();
    }

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    private static String toStringOrNull(List<Integer> list) {
        return CollectionUtils.isNotEmpty(list)
                ? list.stream().map(String::valueOf).collect(Collectors.joining())
                : null;
    }

@Override
public FilterResult shouldInvoke(AuctionRequestPayload payload, AuctionInvocationContext invocationContext) {
final boolean shouldInvoke = random.nextDouble() <= sampleRate;
return shouldInvoke ? FilterResult.accepted() : FilterResult.rejected("rejected by sampling");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inline

Comment on lines +20 to +21
return biddersFilter.isValueAllowed(bidder) ? FilterResult.accepted()
: FilterResult.rejected("bidder " + bidder + " rejected by config");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return ...
      ? ...
      : ...

Comment on lines +47 to +48
@JsonProperty("us_privacy")
String usPrivacy;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for @JsonProperty here. snake_case is a standard for our object mapper

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for raising this. It made me realise how tightly coupled and risky it was to rely on the shared mapper.

The global ObjectMapper uses SNAKE_CASE today, but that is an implementation detail of the host application, not a guarantee this module can rely on. What's more, several fields use names that don't follow SNAKE_CASE at all like providerMetadata or _trace. They are defined by the ID5 API schema, not by any naming convention. Removing @JsonProperty and trusting the naming strategy would silently couple the wire format of ID5 requests to the
host configuration. If that ever changes, the breakage would be invisible at compile time and potentially very hard to trace in production.

HttpFetchClient will own a private ObjectMapper configured independently of the application-wide one. Together with explicit @JsonProperty annotations, this guarantees the request is serialized according to the ID5 API schema regardless of any changes to the host configuration.

Comment on lines +52 to +59
@JsonProperty("gdpr_consent")
String gdprConsent;

@JsonProperty("gpp_string")
String gppString;

@JsonProperty("gpp_sid")
String gppSid;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

Comment on lines +69 to +81
String channel;
String channelVersion;
Id5IdModuleProperties id5ModuleConfig;
Publisher publisher;
List<String> bidders;
}

@Builder
@Value
public static class Publisher {
String id;
String name;
String domain;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add empty lines between fields and after class name

import java.util.Map;
import java.util.Optional;

public record FetchResponse(Map<String, UserId> ids) implements Id5UserId {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

store eids list instead

Comment on lines +10 to +20
private BidRequestUtils() { }

public static final String ID5_ID_SOURCE = "id5-sync.com";

public static boolean isId5IdPresent(BidRequest bidRequest) {
return Optional.ofNullable(bidRequest.getUser())
.map(User::getEids)
.map(eids -> eids.stream().anyMatch(eid -> ID5_ID_SOURCE.equals(eid.getSource())))
.orElse(false);
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    public static final String ID5_ID_SOURCE = "id5-sync.com";

    private BidRequestUtils() {
    }

    public static boolean isId5IdPresent(BidRequest bidRequest) {
        return Optional.ofNullable(bidRequest.getUser())
                .map(User::getEids)
                .orElse(Collections.emptyList())
                .stream()
                .anyMatch(eid -> ID5_ID_SOURCE.equals(eid.getSource()));
    }

private static final Logger LOG = LoggerFactory.getLogger(Id5IdFetchHook.class);

public static final String CODE = "id5-user-id-fetch-hook";
private final FetchClient fetchClient;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add empty line

public Id5IdFetchHook(FetchClient fetchClient,
List<FetchActionFilter> filters,
Id5PartnerIdProvider partnerIdProvider) {
this.fetchClient = Objects.requireNonNull(fetchClient);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add empty line

Comment on lines +37 to +39
this.filters = ImmutableList.<FetchActionFilter>builder()
.addAll(Objects.requireNonNull(filters))
.build();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.filters = Objects.requireNonNull(filters)

private static final Logger LOG = LoggerFactory.getLogger(Id5IdInjectHook.class);

public static final String CODE = "id5-user-id-inject-hook";
private final String inserter;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add empty line

Comment on lines +35 to +37
public Id5IdInjectHook(String inserter) {
this(inserter, java.util.List.of());
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove, we don't keep code for tests


public Id5IdInjectHook(String inserter, List<InjectActionFilter> filters) {
this.inserter = inserter;
this.filters = List.copyOf(Objects.requireNonNull(filters));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for copy

"id5-user-id-inject: updated user with id5 eids"))
.build();
});
} catch (Exception e) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question

Comment on lines +113 to +115
if (filters == null || filters.isEmpty()) {
return FilterResult.accepted();
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this part

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants